Skip to content

Add mapi command - #69

Merged
IvanKiral merged 9 commits into
masterfrom
add_mapi_command
Sep 7, 2026
Merged

Add mapi command#69
IvanKiral merged 9 commits into
masterfrom
add_mapi_command

Conversation

@IvanKiral

@IvanKiral IvanKiral commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds kontent mapi <endpoint>, a curl-like passthrough to the Kontent.ai Management API: you give it a path, it authenticates the request and prints the response. Also lands the two things that command needed to exist cleanly — a proper stdout/stderr split across the whole CLI, and generated command reference docs — plus an e2e suite that runs the built binary against a real cloned environment.

What's in it

kontent mapi (src/commands/mapi, src/core/mapi, src/lib/mapi/raw)

  • kontent mapi 'types?limit=10' --envId <id>, -X/--method, repeatable -H/--header, --input <file|->, -i/--include.
  • Auth resolution, each source suppressing the ones below it: an Authorization header → --mapiKeyKONTENT_MAPI_KEY → the stored login token. Reading the env var directly (not as a yargs option) keeps the key off argv, out of ps and shell history.
  • A 4xx/5xx is a result, not an error: the body still goes to stdout and the exit code is 1, so a failing request stays scriptable.

Logging refactor (src/log.ts) — all logging goes to stderr through an injectable Logger; --logLevel none|standard|verbose and --verbose.

Generated docs (scripts/generateCommandDocs.ts)pnpm docs:generate replays the yargs registrations against a recording proxy and rewrites the root README table plus each command folder's <!-- reference --> block. CI fails when they're stale.

e2e (test/e2e) — clone-per-run from an empty template environment, gated on E2E_MAPI_KEY/E2E_SOURCE_ENV_ID, own vitest config, separate workflow (fork PRs skipped, no secret access).

Decisions worth calling out

Output channels. stdout carries only the data the command exists to produce and is never level-gated; stderr carries everything said about producing it. --logLevel none must still print a response body — a payload is not a log. This also fixed kontent telemetry status, which logged its report at info and so vanished under --logLevel none and never piped to grep; core now returns the report and the command writes it out.

Which HTTP layer to sit on. Went through the full loop here. Not getDefaultHttpService — it maps every non-2xx to an error and keeps the body only when it matches the Kontent error shape, which would lose exactly the 4xx bodies this command exists to show. Briefly replaced core-sdk's HttpAdapter with a bespoke raw-bytes transport so a CSV or binary body wouldn't come back as empty stdout — then reverted it: MAPI answers application/json on every endpoint and every status, and binary only ever travels request-side on an asset upload. A second HTTP path with its own abort and header handling was cost for a case that does not arise. The adapter stays; the body decision is made from the response's content type, not from the payload (core-sdk yields null for absent, skipped, and literal-JSON-null bodies alike), and a non-JSON body is reported on stderr rather than silently vanishing.

Endpoint scoping is a guard, not a privilege boundary. The host is always pinned to MAPI and the caller only escapes their own scoping — but the guard split on forward slashes only, so 'types\..\..\secret' walked past it (WHATWG treats backslashes as separators in an https URL). Now splits on both. Percent-encoded separators need no handling: %2f/%5c stay encoded and can't traverse; only %2e%2e decodes into a traversing segment, which the per-segment decode already covered.

429 backoff. Bounded, abortable, and RFC-correct. Past a minute the API is rationing quota rather than smoothing a burst, so the 429 goes back to the caller with a warning naming the requested delay instead of sleeping an hour three times over. The sleep runs through node:timers/promises with the request's abort signal — the command installs a SIGINT handler, so the first Ctrl+C previously did nothing at all. Number() accepted values delta-seconds does not ("" → 0, -5/1.5 fell through to Date.parse as years → a past date → immediate retry); parsing now requires 1*DIGIT, and the HTTP-date branch requires a letter.

Dropped .env("KONTENT"). It turned every KONTENT_* var in the shell into a CLI flag, and .strict() then rejected the ones the running command didn't declare — an unrelated KONTENT_PROJECT_ID broke every command, and a stray KONTENT_INPUT silently turned a plain listing into a POST of that file. Nothing depended on the mapping; every supported variable is read from process.env where it applies.

-X GET --input is rejected up front. curl allows a GET with a body, but undici refuses one, so this used to die on a raw Request with GET/HEAD method cannot have body. This is where curl parity stops.

--input sends application/json unless a header overrides it — documented explicitly, because MAPI stores that header as the asset's MIME type, so a PNG uploaded without -H was served as JSON with exit code 0 and no warning.

Checklist

  • Code follows coding conventions held in this repo
  • Automated tests have been added
  • Tests are passing
  • Docs have been updated (if applicable)
  • Temporary settings (e.g. variables used during development and testing) have been reverted to defaults

How to test

test/unit covers endpoint resolution, header/method parsing, credential resolution, Retry-After, response presentation, and a test that drives the real core-sdk adapter to assert the duplicated JSON content-type rule still agrees with it. test/integration covers command-level behavior by folding register over a real yargs instance. Several fixes were also verified against the built binary (noted in their commits).

Manually, against a real environment:

pnpm build
kontent mapi 'types?limit=10' --envId <id> --mapiKey <key> | jq        # payload on stdout, pipes
kontent mapi types --envId <id> --logLevel none                        # still prints the body
kontent mapi 'items/does-not-exist' --envId <id>; echo $?              # 4xx body on stdout, exit 1
kontent mapi 'types\..\..\secret' --envId <id>                         # rejected by the traversal guard
KONTENT_FOO=1 kontent --help                                           # stray KONTENT_* no longer breaks commands

End-to-end: pnpm test:e2e with E2E_MAPI_KEY and E2E_SOURCE_ENV_ID set (clones an environment per run).

@IvanKiral
IvanKiral marked this pull request as ready for review September 2, 2026 10:31
@IvanKiral
IvanKiral requested a review from a team as a code owner September 2, 2026 10:31
Comment thread src/commands/mapi/request.ts Outdated
Comment thread src/commands/mapi/request.ts
Comment thread src/commands/mapi/request.ts Outdated
Comment thread .github/workflows/e2e.yml Outdated
Comment thread src/commands/mapi/request.ts
Comment thread test/integration/mapi.test.ts Outdated
Comment thread src/commands/mapi/request.ts Outdated
Comment thread src/lib/mapi/raw/client.ts Outdated
Comment thread .github/workflows/ci.yml
Comment thread src/lib/mapi/raw/endpoint.ts
IvanKiral and others added 9 commits September 7, 2026 07:57
stdout is reserved for command payloads; progress, warnings and errors go
to stderr. Handlers build a Logger via createLoggerFromArgs and pass it
into core. Warnings get a yellow prefix.
* feat: add `kontent mapi` raw Management API passthrough command

* refactor: polish raw mapi command and harden its tests
* feat: generate command reference docs from yargs definitions into colocated READMEs

* ci: fail when generated command docs are stale
E2E_ENV_ID_FILE moves to step-level env (runner context is invalid in job
env). Unset E2E_* now errors instead of silently skipping the suite.
- drop yargs .env("KONTENT"); read env vars explicitly, implement KONTENT_MAPI_KEY
- --header nargs(1), reject GET with --input, guard backslash traversal
- payloads always on stdout, non-JSON body reported by content type, swallow EPIPE
- stream --input via openAsBlob
- retry, Retry-After and header merging handed to core-sdk
yargs resolves `--version` by walking up from its own location to the
nearest package.json, which in a bundled install is not ours. Pass the
same `cliVersion` telemetry already reads, so the flag and the reported
version cannot drift.

Also ignore `*.tgz`, the output of `pnpm pack`.
…nput

Error messages walk the undici cause chain. --envId gets the traversal
guard. --input accepts a pipe or /dev/stdin; signals are re-raised after
the telemetry flush so a blocked open can die.
@IvanKiral
IvanKiral merged commit 9768057 into master Sep 7, 2026
2 checks passed
@IvanKiral
IvanKiral deleted the add_mapi_command branch September 7, 2026 08:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants